컴포넌트 : 제어의 역전으로 재사용성 높이기

결국 컴포넌트의 재사용성은 커스터마이징이 쉽냐 여부로 결정된다고 생각한다. 지금 내가 이미 만들어진 걸 활용하지 못한다면, 결국 또 다른 컴포넌트를 정의하거나 Variant로 분기처리해서 쉽게 처리하고 싶은 욕구가 생겨난다. 새롭게 정의하거나 Variant로 각 사용처마다 분기 처리하면 꼭 나중에 유지보수성, 가독성, 확장성에 걸림돌이 된다.

컴포넌트 재사용성을 높일 때 중요한 개념 중 하나가 제어의 역전(Inversion of Control, IoC) 이다. IoC는 컴포넌트가 스스로 모든 동작을 결정하지 않고, 일부 제어권을 외부(사용자)에게 넘기는 설계 방식이다. 내부 구현을 강하게 고정하지 않기 때문에, 다양한 사용처에서 하나의 컴포넌트를 유연하게 사용할 수 있다.

IoC는 원래 프레임워크 분야에서 사용되던 개념으로, 제어 흐름의 주체를 뒤바꾼다는 뜻이다. React Component에 이 개념을 도입한다면, 컴포넌트를 호출하는 사용처에서 제어를 할 수 있는 것을 의미한다.

개인적으로 재사용을 위한 컴포넌트를 설계할 때 기본적인 상태관리 규칙도 신경쓰지만, 컴포넌트 설계 부분에선 함수 props, Composition Pattern(합성패턴), Slot Pattern, RenderProps Pattern 등 제어의 역전 개념을 고려하고있다.

먼저, 재사용할 컴포넌트의 기준은 어떻게 세우냐면

다음과 같은 경우에는 컴포넌트를 추상화하거나 구조를 개선해 재사용성을 높이고 있다.

  1. 하나의 기능이지만 여러 곳에 정의되어 있는 경우나 그럴 것이라 예상되는 경우
  2. 컴포넌트 Variant로 분기처리하고 있는 경우
  3. 기능을 추상화해서 재사용하면 생산성이 높아질 경우

하나의 기능이지만 UI가 다르단 이유로 여러곳에서 복제하고 있으면, 나중에 공통 로직을 변경해야 되는 경우 여러곳에서 작업해야한다. 개발자들끼리 센스있게 파일 위치를 한 곳에 모아둔다면 괜찮겠지만, 그러긴 쉽지 않기 때문에 유지보수성이 떨어질 수 밖에 없다.

컴포넌트 내부에서 Variant로 분기처리하는 방법은 컴포넌트 초반 구현 시점에는 굉장히 깔끔해보인다. 하지만 애자일하게 변해가는 프로덕트에선 수 많은 변종들이 생기기 마련이다. 그렇게 누군가 Variant를 하나씩 붙여나간다면, 나중에는 특정 Variant에만 UI를 수정하고 싶어도 무서워진다. 꼭 사이드이펙트가 발생하더라.

기능을 추상화하는 것을 좋아한다. 예를 들어, 컴포넌트가 마운트되는 시점에 translate 애니메이션이 동작해야 되는 경우나, 클릭을 하면 로깅을 하거나 알럿을 띄우는 로직을 useEffect 등을 통해서 구현하게 된다. 이런 기능의 추상화를 사용한다면 코드량을 줄이고 생산성을 높이는데 도움이 되는 것을 경험했다. 또, 기능을 공통적으로 변경하는 것에도 강점이 있다.

제어의 역전을 고려하여 컴포넌트 설계하기

개인적으로 재사용을 위한 컴포넌트를 설계할 때 기본적인 상태관리 규칙도 신경쓰지만, 컴포넌트 설계 부분에선 함수 props, Composition Pattern(합성패턴), Slot Pattern, RenderProps Pattern 등 제어의 역전 개념을 고려하고있다.

1. 합성 컴포넌트 패턴 (Composition Pattern)

가장 애용하는 패턴이다.

1-1. 기능은 같은데 사용처마다 UI가 달라야 할 때

사용처마다 UI가 다를 때, 합성 컴포넌트 패턴을 고려하여 설계한다.

// ProductCard 컴포넌트
function ProductCard({ children }) {
  return <div className="product-card">{children}</div>;
}

ProductCard.Image = ({ src }) => <img src={src} className="product-image" />;
ProductCard.Title = ({ children }) => <h2 className="product-title">{children}</h2>;

// 사용 예시
<ProductCard>
  <ProductCard.Image src="/a.png" />
  <ProductCard.Title>상품명</ProductCard.Title>
  <p> 지원하지 않는 컴포넌트는 이렇게 사용처에서 만들어서 사용하기</p>
</ProductCard>;

이렇게 UI는 사용하는 곳에서 마음대로 조합할 수 있기 때문에 Variant가 필요 없고
아직 지원하지 않는 요소가 있다면, 일단 내가 만들어서 사용할 수 있으니까 새롭게 정의되는 문제를 피할수도 있다.

1-2. 어떤 기능인지 명확하게 표현할 때도 사용한다

예를 들어 TR 유저인지 RP 유저인지 구분해 렌더링해야 하는 기능이 있다고 하자. 그냥 <TR />, <RP /> 같은 이름으로 두면 맥락이 불명확하거나, 다른 도메인의 컴포넌트와 헷갈릴 수도 있다.

이럴 때는 의도적으로 “네임스페이스 역할”을 할 수 있도록 합성 컴포넌트로 만들어 사용하는 편이다.

<Segment.TR />
<Segment.RP />

컴포넌트만 봐도 구조와 의미가 명확해진다. 합성 패턴은 네임스페이스 역할에도 적합하다.


2. 슬롯 패턴 (Slot Pattern)

모든 요소를 합성 컴포넌트로 쪼개면 사용 시 지나치게 장황해진다. 또, 모든 파트를 세밀하게 합성 컴포넌트로 만들어두는 것은 작업량이 많아 비효율적이다.

이럴 때는 큰 구조만 합성 컴포넌트로 만들고, 그 내부 중 변경 가능성이 높은 슬롯 위치만 Props로 분리하는 방식이 효율적이다.

// Card 컴포넌트
const Card = ({ title, footer, children }) => (
  <div className="card">
    <div className="card-header">{title}</div>
    <div className="card-body">{children}</div>
    <div className="card-footer">{footer}</div>
  </div>
);

// 사용 예시
<Card title={<h2>제목입니다</h2>} footer={<button>닫기</button>}>
  <p>카드 내용입니다.</p>
</Card>;

Slot은 "여기 들어갈 내용은 사용자가 정하라"는 방식이기 때문에 UI 재사용성과 유연성이 크다.


3. 함수 Props

컴포넌트 내부의 핵심 동작 또는 제어 로직을 변경해야 할 때 함수 Props를 사용한다. 어떤 행동(클릭, 제출, 필터링, 정렬 등)을 사용자가 원하는 방식으로 교체할 수 있도록 열어두는 방식이다.

간단하게 우리가 onClick 함수를 <button>에 전달하는 것처럼 설계하는 것이다.

<List items={items} filter={(item) => item.type === 'active'} />

핵심 로직(onSearch)을 외부에서 주입하기 때문에 컴포넌트는 “입력 → 동작 트리거”만 담당하고, 실제 동작은 사용자가 원하는 방식으로 설정할 수 있다.


4. 렌더 프롭스 패턴 (Render Props Pattern)

함수 Props가 코어 로직을 제어한다면, 렌더 프롭스는 그 대상이 UI인 것 뿐이다. 그저 렌더링할 컴포넌트를 변경할 수 있게 해주는 것이다.

// DataProvider 컴포넌트 (로직/데이터 관리)
const DataProvider = (props) => {
  const data = 'Hello World'; // 공유할 데이터 또는 로직
  return props.render(data); // 받은 함수에 데이터 전달하여 호출
};

// 사용 예시 (UI 정의)
// DataProvider의 데이터를 사용하여 UI 렌더링
<DataProvider render={(data) => <h1>{data}</h1>} />;

4-1. 개인적으로는 RenderProps를 더 이상 활용하지 않고 있다.

child as function 패턴과 함께 RenderProps 패턴은 더 이상 사용하지 않고 있다. 이유는 Next.js에서는 RSC에서 RCC로 함수를 전달할 수 없기 때문이다. 간단히 말하자면, 우리가 JSON에다가 함수를 넣지 못하는 이유와 같다. RSC에서 RCC에게 Props는 직렬화 되어 전달되는데 함수는 직렬화-역직렬화가 안되기 때문이다.

슬롯패턴은 그냥 React.createElement() 호출문으로 변경되어 그저 JS 객체가 될 뿐이지만, 렌더프롭스는 함수기 때문에 전달이 안된다.


결론

일반적으로 재사용성을 고려한 컴포넌트를 설계할 때는, 기본적인 상태관리와 재사용성을 위한 고민을 하고있다. 그리고 디자인 시스템 레벨의 컴포넌트를 구현할 때는 다형성, data-*, controlled, uncontrolled state 까지 고려하고 있는데, 이 부분은 다음에 포스팅할 예정이다.